perf(run): reads proportionate to what they show, and the case pass batched - #544
Conversation
Opening a case measured 43s on a cold read and ~4s warm against the pilot's six-measure nightly, with no run in flight — so this was never load. `routes/cases.ts` fetched every outcome row of the run, `evidence_json` blobs included, and then `.find()`d the single row the page renders. The cost grew with the roster rather than with anything the page shows: 120,000 rows over the wire into a single-replica worker to use one of them. `listOutcomes` gains an optional `subjectId` alongside the `measureId` added in #543, filtered in SQL in both stores, and the route asks for `{ subjectId, measureId, limit: 1 }`. `limit: 1` is exactly equivalent to the old `.find()`: the store orders by `evaluated_at ASC, id ASC`, so the first row under the same filter is the row `.find()` would have returned. Both columns are index-friendly — `outcomes` carries `(subject_id, measure_id, evaluation_period)` and `(run_id)`. The store-contract test pins the composition of the two filters and asserts the result is the same row the unfiltered `.find()` picked, so an implementation that narrowed differently fails rather than quietly returning a neighbour.
… definition The whole-run read fixed in the previous commit was written out eight times. A read-path audit found it at case-actions (the detail returned by EVERY case mutation — assign, escalate, resolve, priority), case-outreach (twice per send: renderContext then buildDetail, and again on every "Preview message"), appointment-service (so the case detail page paid it TWICE per view, once for the case and once for its appointments), routes/ai (before the explanation cache is consulted, so even a cache hit paid it), case-rerun, both MCP case tools, and the auditor case packet. All now call `outcomeForCase(outcomes, lastRunId, subjectId, measureId)` — one row, chosen in SQL. A shared helper rather than eight edits, so the ninth copy has somewhere to go instead. `audit-packet.ts` was invisible to the grep that found the others: it contains two literal NUL bytes as a composite-key joiner, so grep classifies it as binary and skips it. Intentional and committed, but worth knowing it hides from search.
…he whole tenant
The patient profile — the page a quality lead opens to see one person's quality — read
`listCases({ limit: 100000 })`, every case in the tenant, and kept the ones whose employeeId matched.
It scaled with the practice's case count rather than with the patient being looked at.
`CaseQuery` gains `employeeId`, filtered in SQL on both stores. The contract test pins that it
composes with `measureId` rather than replacing it, and that omitting it does not filter — a filter
that silently won the other would go unnoticed at demo scale and only bite the pilot.
The nightly evaluates 120,000 (subject, measure) pairs and the case pass awaited two round trips per pair — a SELECT then an INSERT or UPDATE — plus a third for each audit event. Measured on the pilot at 9.5 pairs a second, about three and a half hours; the run of 2026-09-08 was killed by a deploy at 87,000 and then showed RUNNING for sixteen hours. Outcomes were already batched; the case pass was not. `CaseStore.upsertFromOutcomes` takes a chunk and returns one result per input, in input order, null exactly where the single-row call returns null. Postgres reads every existing row for the chunk's keys in one `unnest` join, plans in memory with the same pure `planCaseUpsert`/`planNextAction`, then writes one multi-row INSERT and one set-based UPDATE. `CaseEventStore.appendAudits` does the same for the ledger. Measured against a real postgres:16, 3,000 pairs, results asserted identical to the sequential path: 5,250 round trips to 12, and 4,151ms to 553ms locally — where a local socket pays none of the ~40ms Neon costs per trip. At that RTT the case pass alone was ~140 minutes of pure latency per nightly. ADR-076 d2 survives as a compare-and-set in the WHERE of the set-based UPDATE, comparing the `next_action` we READ. A row an operator moved in between matches nothing and falls back to `upsertFromOutcome` for that row alone — the proven path, with its re-read, its three attempts and its action-preserving fallback. `planNextAction` stays the single definition of the rule; expressing it as a SQL CASE would make the pure function dead where it matters. Same for a key another writer inserted first. A duplicate key inside one batch THROWS rather than resolving arbitrarily: a set-based UPDATE would apply one of the two silently where the sequential path applied both in order. The SQLite floor is a loop, and says so — the batching buys round trips, and a local file has none. That means every batch-shaped contract test passes on the floor without exercising set-based SQL, so the Postgres ceiling is where this is really tested. Both failures found while writing it were Postgres-only: an INSERT placeholder computed from a moving `binds.length` inside the row loop, and an ambiguous `RETURNING` once the UPDATE joined a VALUES alias carrying the same column names. The audit batch is awaited through `Promise.resolve().then(...)`, not a bare `.catch()` on the call: a `.catch()` handles a rejection, but a synchronous throw escapes it and would take the cycle rollover and the terminal event down with it. The old per-row call had the same latent hole.
… fails the run, and tests that can fail Three reviewers, two of them independent, converged on the same list. CRITICAL, and a real data defect: the set-based UPDATE hoisted `toUpdate[0]`'s `runId` and stamped it on every row in the chunk, while the INSERT path used the row's own. Reproduced against a live postgres:16 — a two-row batch with different run ids wrote the first id to both. `last_run_id` is the evidence pin §6.5 relies on to survive outcome compaction, and it is what `countByLastRun` counts, so a case would have been pinned to a run that did not produce it. The pipeline passes one run id per chunk today, so it was latent — and invisible to the SQLite floor, which loops and is correct by construction. `last_run_id` is now a per-row column in the VALUES list, and the probe passes. The duplicate-key refusal was correct in the store and wrong at the boundary: it threw inside the chunk loop, so a repeated key would fail a three-hour run outright where the per-row loop had absorbed it last-wins. A repeat is reachable — the live WebChart path builds items per fetched bundle, so two bundles for one patient produce two items for one key, in the same chunk. The pipeline now collapses duplicates last-wins, matching what it replaced, and logs a WARN so the upstream duplication is still visible. The store keeps the throw as the backstop. `appendAudits` was all-or-nothing per 500-row statement, so one malformed payload cost up to 500 ledger entries against the rule that every state change is audited. A failed sub-chunk now falls back to a row at a time — the same "losers take the proven path" shape the case store uses — and still propagates so the caller hears that the ledger is incomplete. Tests that could not fail, which is the defect class this repo names and I have now hit three times: - the "operator mid-batch" contract test patched BEFORE the batch, so the pre-read saw OPERATOR and the row WON the compare-and-set. It never reached the fallback. Renamed to what it actually proves, and a real race added in the Postgres file — it interposes on the `unnest` pre-read, which is the window the batch cannot see. It belongs there because the floor has no set-based UPDATE to lose. - the equivalence test ran against a fresh store, so every input took the INSERT path and the set-based UPDATE never executed. A new test seeds first and mixes inserts, updates and no-ops in one batch, and pins the two §4 guarantees worth pinning for a batch: IN_PROGRESS survives, a human closure is not reopened. - nothing crossed the 500-row sub-chunk boundary. A 1,200-row batch with every third input a no-op now does, so a shifted index shows up as a null in the wrong slot. Two comments asserted invariants that had stopped being true: the progress counters advance before any case is written now (they still describe persisted OUTCOMES, which is what they always counted — the comment claimed cases), and a mid-run failure is coarser than per-row, in the recoverable direction. Also: raw NUL bytes had crept into five source files as key separators. A NUL makes git and grep treat a `.ts` file as binary, so the file drops out of diffs and out of every search — `audit-packet.ts` had been hiding from code search this way for months, which is why a whole-run read in it survived. Same runtime value, written as `\u0000`. Both stores now join duplicate keys identically; the floor had a space, so a subject id containing a space would have been refused by one store and accepted by the other.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad101a107a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| WHERE c.employee_id = v.employee_id AND c.measure_id = v.measure_id AND c.evaluation_period = v.evaluation_period | ||
| AND c.next_action IS NOT DISTINCT FROM v.expected_next_action | ||
| AND c.next_action_source IS NOT DISTINCT FROM v.expected_next_action_source |
There was a problem hiding this comment.
Recheck case status before applying batched updates
When this batch has pre-read an OPEN case and scheduleAppointment concurrently changes only its status to IN_PROGRESS, these predicates still match because that action does not modify either next_action field. The update then writes the stale planned status (OPEN) and silently undoes the scheduling state, violating the state-aware upsert contract that preserves IN_PROGRESS. Include the status-related planning fields in the compare-and-set or re-read/lock the row before applying the update.
Useful? React with 👍 / 👎.
…as made from Codex P2 on #544, and it is right. The CAS compared `next_action` and `next_action_source` only, but `planCaseUpsert` reads `status`, `current_outcome_status` and `closed_by`, and `planNextAction` reads the two action columns plus `current_outcome_status` — five inputs, two guarded. So a concurrent write touching neither action column slipped through. `scheduleAppointment` is exactly that: `patchCase(caseId, { status: "IN_PROGRESS" })` and nothing else. The batch's planned `status: "OPEN"` then landed on top of it and silently undid the scheduling, against §4's most-cited guarantee. The single-row path guards the same two columns and has the same hole, but its read-to-write window is microseconds; a batch plans a whole chunk and issues an INSERT before its UPDATE, so the window is orders of magnitude wider. The batch is now strictly stricter than the per-row path, which is the right asymmetry: a row that fails the wider guard falls back to `upsertFromOutcome`, which re-reads, re-plans against the row as it now is, and gets the correct answer. The new Postgres test interposes on the `unnest` pre-read and patches only the status, reproducing the exact scenario. Verified non-vacuous: with the three added predicates removed it fails on "the operator's IN_PROGRESS survived the batch's stale plan", and passes with them. Fixing it also caught a cast/column misalignment of my own — the three new expected_* values are pushed before `runId`, so `::uuid` had to move from position 14 to 17. The VALUES cast list now carries a numbered comment mapping every position, because this is the second time an off-by-one there has cost a debugging round.
|
Fixed in db8489d — you were right, and the mechanism is exactly as described.
The compare-and-set now covers all five — "only write if the row is still the row I planned from". A row that fails it falls back to the per-row Worth noting the single-row path has the same two-column guard and the same hole; what made it worth closing here is that a batch plans a whole chunk and issues an INSERT before its UPDATE, so the read-to-write window goes from microseconds to hundreds of milliseconds. The batch is now strictly stricter than the per-row path. Added a Postgres test that interposes on the |
…the measurement-year report (#574) * feat(lists): the ACO's attributed list is an immutable, revisioned table on both stores The one concrete ask from the 2026-09-09 working session: hand WorkWell the list of patients the ACO attributes to the group, run the measures over exactly that subset, and get numerator/denominator/exclusions back with patient-level results. Every other population question this deployment answers is "the patients in our directory"; this one is "the patients somebody else says are ours". Two tables, owner-authorized in session on 2026-09-15. IMMUTABLE is the design rather than a nicety: a report is a function of (list revision, run ids), so a list that could be edited underneath one would make every number the ACO had already filed unverifiable. A re-import is a new row with revision+1 under the same name, the store interface exposes no mutator, and a manual resolution of a non-match is therefore also a new revision — the rule ADR-022 applies to identity matching. NOT_FOUND members are KEPT. An identifier the directory cannot resolve is the ACO and the practice disagreeing about who a patient is, which is a finding for the review queue; dropping it would remove the patient from the denominator and from the evidence that they were ever claimed. `resolution` and `subject_id` are coupled by a CHECK rather than by convention, and a partial unique index refuses a second MATCHED row for one subject, so an alias lands AMBIGUOUS rather than doubling a patient in every denominator the list feeds. Nothing is visible until the whole import landed — header IMPORTING, members in chunks, audit, then COMPLETE, with every read filtering COMPLETE. "One transaction" was never available on the floor (D1 caps a batch at ~90 statements), so rather than let the ceiling be quietly safer, both backends rely on the flip and a crash at any point leaves an invisible partial row instead of a short list masquerading as whole. The revision allocation IS transactional, because it is the only part with a real race: an advisory xact lock on the ceiling (never the session form — Neon's pooler is PgBouncer in transaction mode), a single INSERT…SELECT MAX+1 with one retry on the floor. The import rules are pure and tested where they can fail. Two are invisible from a route test: resolution never consults a lookup that FABRICATES profiles (the live directory returns a minimal profile for any `wc|` string, so a resolver built on it would auto-match identifiers that exist nowhere), and the alias collapse cannot fire under today's exact-id matching but is the seam the identifier format changes at. The sandbox data boundary is per PROFILE, and writing it as one pattern was the first thing that would have been wrong twice: the Maui corpus is 48 fixture patients with three-digit ids followed by generated five-digit ones, so a gate written for the generated form alone would refuse the first 48 real patients in the sandbox; and the default deployment is a different synthetic roster (emp-NNN), where the corpus pattern would refuse everything. It is a namespace test, not an existence test — pat-99999 conforms and is simply NOT_FOUND, so the review queue is exercised with synthetic-shaped ids. Verification: 12 store-contract tests green on the SQLite floor; 13 import-rule tests green. The Postgres ceiling contract runs in CI's postgres:16 service — Docker is down on this host and the floor cannot catch Pg-only SQL, which is how two defects reached #544. * feat(lists): import an attributed list, and filter every subject surface by it The import route, its sandbox data boundary, and `?listId=` through the one predicate six surfaces already share. EVERY method on /api/subject-lists is CASE_MANAGER/ADMIN, metadata included — deliberately not split into a GET rule and a write rule the way panels is. A member row is a raw patient identifier another system asserted, and the list's mere existence says which patients an ACO claims. The public /sandbox signs in as a read-only VIEWER that may browse every AUTHENTICATED GET, and on Maui the clinician seat is a VIEWER too, so leaving the reads to the /api/** catch-all would have handed all of it to anyone who pressed "explore the sandbox". The sandbox data boundary refuses the WHOLE upload before persistence when any identifier is outside this deployment's own namespace, and reports the COUNT rather than the values — an error body is logged and kept by the browser, so echoing them would persist the very data the gate refuses. A live-directory deployment cannot import at all: that directory is a worker-local last-known registry that also fabricates profiles for `wc|` ids, so matching against it would be silently incomplete — every row MATCHED and every denominator wrong — rather than merely unavailable. The gate runs before the body is read. The audit payload carries counts, provenance and revision and NO identifier; a test asserts it, because audit_events is exported wholesale and an identifier there would defeat the namespace gate by another route. `?listId=` is a RESOLVED membership, never a token the predicate parses: the parameter names an immutable list and the server turns it into subjects, so a client cannot spell one. The set is memoized in an 8-entry LRU, which is safe only because a list is immutable — a 50,000-id Set rebuilt on every /worklist page load is the pool-pressure shape #560 removed from the case export. `hasActiveSubjectFilters` tests `!= null`, not `.size > 0`: a list none of whose identifiers resolved is a legitimate answer and an ACTIVE filter matching nobody, and reading it as absent would answer "the ACO's population" with everybody. Verification: 12 route tests, 2 Maui-profile gate tests (each profile's own namespace is the admitted one — a single pattern would have refused the first 48 corpus patients on one profile and everything on the other), 6 cross-surface tests, 1 authorization test; 108 existing route tests still green. Two mutations, each caught: `.size > 0` in the active-filter guard revives the empty-list hole, and dropping the resolution from the cases CSV breaks all three surface tests. * feat(report): the attributed list's measurement-year numbers, with patient-level evidence What the ACO actually asked for on 2026-09-09: numerator, denominator and exclusions over the patients they attribute to the group, with the per-patient result and its date. Computed from the same evidence every other rate here comes from — `createRateAggregator` over the outcomes' persisted population memberships — fed only the rows whose subject is in the list. Four things that were each a way to be quietly wrong: `measurementYear` is REQUIRED and has no default. An officially routed run is scored over the calendar year containing its evaluation date (ADR-072), so in January 2028 the newest run is a PY2028 one and "the latest numbers" would answer a PY2027 question with next year's first nightly — a wrong number that looks exactly like a right one. The selector walks past a newer run from the wrong year. Compaction is checked PER MEASURE, before and after the reads. ADR-077 refuses a report built over rows that may be incomplete, but the refusal belongs to the measure whose run aged out; withholding five complete measures because the sixth did would be a second wrong answer. The whole request is 409 only when every selected run is exposed. The post-read check is what makes a mid-report compaction pass a 409 rather than a truncated 200 — a streamed CSV cannot change its status after the first byte, so every derived row is computed before anything is serialised. `missingFromRun` is reported BESIDE the rates and never subtracted. A member the run never evaluated is a gap in the evidence, not an exclusion, and folding them into a denominator would let a SMALLER run produce a HIGHER score. Two reconciliations are pinned by test: matched = seen + missing, and seen = scored + unmeasured + errors + outOfPopulation. The CSV is the patient-level artifact, with its header pinned exactly because the ACO's tooling reads it by name. Three row shapes: one per (measure, rate) when evaluated; one per MEASURE for a member the run never saw, with every population cell EMPTY rather than 0 (which reads as a scored result); and exactly ONE row per unresolved identifier, after the evaluated rows — one per measure would multiply one disagreement by six. Text a person supplied is neutralised against spreadsheet formula injection, because a CSV of somebody's uploaded identifiers must not become code when the ACO opens it. `subjectHeaders` is exported rather than copied, so the subject columns follow the deployment's own term. The report read is audited like COMPLIANCE_API_READ, naming the list, revision and run ids — what the December question about March's numbers needs — and no identifier. Verification: 14 report tests, 5 new route tests, 17 route tests in total for the surface. Backend 2,724 tests, 2,700 pass, one failure — the standing corpus-membership stale sparse-checkout one, green in CI. Two mutations, each caught: dropping the post-read compaction check fails 3 tests, and taking the newest run regardless of year fails 5. * feat(lists): the attributed-list screen — import, the review queue, and the year's numbers One page for the ask: upload the list, see what resolved and what did not, and take the measurement year's numbers off it. The unresolved members get a filter of their own rather than a count in a corner. An identifier the directory cannot resolve is the ACO and the practice disagreeing about who a patient is — a finding somebody has to work, and a list that reported only its matched count would hide exactly the rows worth a phone call. The server's refusal is rendered VERBATIM. The sandbox-namespace gate answers with a COUNT of identifiers outside the deployment's directory, and "Import refused" alone would drop the one number the operator needs to go back to the ACO with. The report's measurement year is a select with no default, because ADR-072 scores an officially routed run over its calendar year: "the latest numbers" would answer a PY2027 question with PY2028's first nightly the moment January arrives, and it would look right. A measure whose run predates a retention cutoff is named with its reason beside the measures that did report — withholding the others would be a second wrong answer, and showing that one's numbers would be the first. The lists hook carries the same hardening `use-panel-payers` and `use-assignable-users` needed: a row that is not what it claims to be is dropped, and a payload that arrived wholly unusable warns rather than rendering an empty state that reads as "this deployment has no lists". Both fetch effects defer out of the synchronous body, the idiom compliance/ and cases/ already use for react-hooks/set-state-in-effect; the members table resets its page in the CHANGE HANDLER and remounts per list via a key, so no effect writes state at all. Verification: 5 page tests, frontend lint clean, 471 tests, build compiled. One mutation, caught: dropping the row shape guard fails the picker test. * docs(lists): ADR-082, the report's column contract, and the two PHI-phase items it defers ADR-082 records seven decisions: a list is immutable (a report is a function of list revision and run ids, so an editable list makes every filed number unverifiable); an attribution is not an assignment and neither is a denominator; NOT_FOUND members are kept and an alias is AMBIGUOUS rather than a silent collapse; the sandbox data boundary, per profile and before persistence; the report is for a measurement year and compaction refuses per measure; missingFromRun is reported beside the rates and never subtracted; and the CSV is the patient-level artifact with a pinned header. DATA_MODEL_CONTRACTS gains §6.6 (the report's columns, its three row shapes, and why the two non-evaluated ones carry the empty string rather than 0) and §6.7 (`?listId=` on the six filtered surfaces). Both are APPENDED — nothing in §6.1–§6.5 moves. DATA_MODEL §3.29 carries the two tables and the visible-state machine; MCP.md documents the listId argument and states that it exposes nothing the tool's role gate did not; ARCHITECTURE §3 places the report as the sixth population read model and the only one whose population is asserted from outside; DEPLOY gains the runbook — nothing to run, no backfill, rollback-safe, the request budgets, and the retained-report stopgap that is the ACO's audit trail until the archive ships. PRODUCTION_READINESS §4 gains the two PHI-phase items this defers: an authoritative subject resolver (the live directory fabricates profiles for unknown `wc|` ids, so matching against it would be silently incomplete) and the per-run report archive, with its design, for the ACO's audit window. Guide chapter 10 gains the scenario in plain words, and the journal entry records the two source hazards this session hit: a literal U+FEFF and a literal tab + CR written into regexes by tooling that collapsed the escapes, both invisible on screen and caught only by dumping bytes. * test(e2e): the attributed-list screen and its authorization boundary, read-only Importing is a WRITE and belongs in `maui-writes`; this project runs first and must be harmless when the thing it tests is broken, so every assertion is about a surface that exists whether or not any list has been imported, and the negative checks name a list id no import can have produced. The one thing worth an e2e rather than a unit test: this route's READS are CM/ADMIN, unlike every other directory surface on the deployment. A regression that dropped them to the AUTHENTICATED catch-all would look entirely normal in a unit test of the page, and would hand an ACO's attribution to the read-only sandbox seat. * fix(lists): the review round — a gate that could not fire, and a reconciliation that was false Four review lanes on the whole diff. The two that matter were found independently by more than one of them. `?listId=` re-opened the membership the CM/ADMIN gate exists to close. Every method on /api/subject-lists is CM/ADMIN because the list's existence says which patients an ACO claims — but five of the six surfaces that accept `?listId=` are AUTHENTICATED, so a read-only VIEWER holding a list id could take the whole membership out of `GET /api/exports/cases?format=csv&listId=<uuid>`: names, provider, payer, per-measure status. That is strictly MORE than the members endpoint the gate protects, and the id is not a secret by construction — it sits in the query string of every filtered screen, so it reaches shareable URLs, browser history and access logs. A gate that reads as present and cannot fire for the widest read is this repo's own vacuous-guard shape at the system level. Enforced once in the worker, where authorization decisions live. The stated reconciliation was arithmetically false. `createRateAggregator`'s `unmeasured` is a SUPERSET of its `evaluationErrors` — it starts the count at the error count — so subtracting both double-counted every error, and a row that was both out-of-population and an evaluation error made `scoredSubjects` NEGATIVE. The test that "pinned" it used a fixture with all three counts at zero, which makes the assertion `2 === 2 + 0 + 0 + 0` and passes for any implementation. Each seen subject is now classified into exactly ONE bucket in a stated order (error, then out-of-population, then in-no-rate, then scored), so the identity holds on a PARTIAL_FAILURE run — an ordinary night on the pilot. Also, each verified before folding: - The default profile's namespace refused fifty legitimate `ihn-emp-NNN` members of its own directory — the same defect as refusing Maui's 48 fixtures, in the other deployment. And an unrecognised profile is refused outright now rather than lent another profile's namespace. - The run search took the twelve most recent runs outright, which on a nightly deployment is twelve DAYS: from mid-January a report for the closed year answered "no run" while that year's runs sat uncompacted. `listLatestPopulationRuns` walks at most 25 runs whatever count it is given, so scoping to the year's window was the only mechanism that could work. - The per-row rate flags built a whole aggregator per row — 300,000 of them at the 50,000-member cap across six measures — where `membershipRatesFor` is the thing wanted, and it is read once per row for both the flags and the bucket. - `name`/`source`/`note` were the one unvalidated channel into Neon, and name/source are copied into an audit payload that is exported wholesale. Capped at 200 by the route and by a CHECK in both schemas. - `listMembers` and `countMembers` did not filter COMPLETE, safe only because every caller checked first — a claim about callers, not about the store. - The duplicate collapse compared timestamps as STRINGS, so an offset-form `evaluatedAt` could pick the older clinical evaluation. - The CSV carried the resolved subject id in the `rawIdentifier` column, which is right only while matching is exact and wrong the moment the format changes — which is the entire point of that column. - `emptyEntry` broke `matchedSubjects = seen + missingFromRun` for exactly the entries a reader is most likely to check: the ones with no numbers. - The 2 MB cap was measured after buffering the body, and had no test. - A test titled "the body is never read" asserted only a status code. One claim was checked and REJECTED: a lane reported that an unrecognised profile would fail OPEN and admit real identifiers. It would not — the fallback was the `emp-` pattern, which refuses an MRN — and the profile id is a closed union. The underlying point was still worth taking. Verification: backend typecheck clean, 2,738 tests, one failure (the standing corpus-membership stale sparse-checkout, green in CI). Seven mutations, each caught; the new one is removing the `?listId=` gate, which fails three worker tests. * fix(report): select a run by the period it SCORES, and emit rows for a measure that never ran Four findings from Codex on the open PR, two of them P1. The run selection filtered candidates by when a run STARTED. A manual run takes an arbitrary `evaluationDate`, so a rerun-to-verify of a closed year begins in the following one and legitimately scores the closed one — I had written that limit into ADR-082 as acceptable, and it is not, because a backdated rerun is a supported path rather than a hypothetical. The report would answer `no_completed_population_run_for_year` with that run sitting in the table, which is the shape of wrong answer this project refuses: it looks exactly like a right one. `RunStore.listPopulationRunsForPeriod` filters on the run's own `measurement_period_start`, which also removes the 25-run walk cap that made the start-date window necessary in the first place, and is simpler than what it replaced. A measure with no usable run emitted no patient rows while its summary claimed N members were missing from it. The CSV serialises `rows` alone, so the count was unreconstructable from the artifact and the ACO could not see WHO. It emits one MISSING_FROM_RUN row per matched member now. A COMPACTED measure stays the exception and claims nothing per subject — no rows, `missingFromRun: 0` — because ADR-077 refuses numbers built over rows that may be incomplete, and "how many of your patients did this measure miss?" is such a number. On the screen: the year select offered only past years, which made PY2027 — the year the pilot exists for — unreachable until the clock caught up, even though a run can already be created with that evaluation date. And changing the year left the computed table and the Download button up while `download` read the NEW year, so an operator could read one year's numbers and download another's; an in-flight compute could also land after a year change and repopulate the stale table. Both selects on the page are native `<select>` elements with `aria-label` now, following the roster's page-size control: `@mieweb/ui`'s Select renders a custom combobox whose options are not in the DOM, so `userEvent.selectOptions` cannot drive it, and a control with behaviour worth pinning has to be drivable by a test. Two sibling components were also keyed by the same list id. Verification: backend typecheck clean, 2,742 tests, one failure (the standing corpus-membership stale sparse-checkout, green in CI); frontend lint clean, 473 tests, build compiled. A new store-contract test proves the period read selects a run started in a LATER year, and refuses a CASE-scope rerun and an unfinished run. --------- Co-authored-by: Taleef <taleef@gmail.com>
Two measurements on the live pilot stack started this, both with no run in flight.
Opening a case took 43 seconds cold and about 4 seconds warm. The route fetched every outcome row of the whole run —
evidence_jsonblobs included — and then.find()d the one row the page renders. Roughly 87,000 rows across the wire to use one of them, growing with the roster rather than with anything the page shows.The nightly ran at 9.5 (subject, measure) pairs a second — about three and a half hours. Outcomes were already batched; the case pass was not. Per pair it awaited a SELECT, then an INSERT or UPDATE, then an audit insert.
Reads
A read-path audit found the case-detail query copied in eight places, several worse than the original:
case-actionsis the detail returned by every case mutation, so assigning a case paid it too;case-outreachpaid it twice per send and again on every "Preview message";appointment-servicemeant the case page paid it twice per view; androutes/aipaid it before consulting its own explanation cache, so even a cache hit cost four seconds.All eight now call one
outcomeForCasehelper — one row, chosen in SQL.listOutcomesgained an optionalsubjectIdbeside themeasureIdadded in #543.limit: 1is exactly equivalent to the.find()it replaces: both adapters orderevaluated_at ASC, id ASC, so the first row under the same filter is the row.find()returned. Verified equivalent at 20k and 120k rows — same row id.The patient profile had the same shape one level up:
listCases({ limit: 100000 }), every case in the tenant, filtered in JavaScript.CaseQuerygainedemployeeId.The batched case pass
CaseStore.upsertFromOutcomestakes an evaluation chunk and returns one result per input, in input order,nullexactly where the single-row call returns null. Postgres reads the chunk's existing rows in oneunnestjoin, plans in memory with the same pureplanCaseUpsert/planNextAction, then writes one multi-row INSERT and one set-based UPDATE.CaseEventStore.appendAuditsdoes the same for the ledger.Measured against a real postgres:16, 3,000 pairs, results asserted identical to the sequential path:
Local has no network. At Neon's ~40 ms per round trip the case pass alone was about 140 minutes of the nightly, which is most of it.
ADR-076 d2 survives as a compare-and-set in the WHERE of the set-based UPDATE, comparing the
next_actionthe batch read. A row an operator moved in between matches nothing and falls back toupsertFromOutcomefor that row alone — the proven path, with its re-read, three attempts and action-preserving fallback.planNextActionstays the single definition of the rule; expressing it as a SQLCASEwould have made the pure function dead exactly where it matters.A duplicate key inside one batch throws in the store rather than resolving arbitrarily — a set-based UPDATE would apply one of two silently where the sequential path applied both in order. The pipeline collapses duplicates last-wins before calling, matching what it replaced, and logs a WARN so upstream duplication stays visible.
A local Postgres, which is the part that made this safe
Every store change in this repo has carried a "verified only by CI" caveat.
docker compose -f infra/docker-compose.yml up -d postgresretires it: the ceiling runs 98/98 with nothing skipped.It paid for itself immediately. Both bugs in the new SQL were Postgres-only and passed on the SQLite floor — an INSERT placeholder computed from a moving
binds.lengthinside the row loop, and an ambiguousRETURNINGonce the UPDATE joined aVALUESalias carrying the same column names. The floor is a loop by design and can catch neither.What review caught
Three reviewers; the two that completed converged independently on the same list.
CRITICAL — a real data defect. The set-based UPDATE hoisted
toUpdate[0]'srunIdand stamped it on every row in the chunk, while the INSERT used each row's own. Reproduced against the live database: a two-row batch with different run ids wrote the first id to both.last_run_idis the evidence pin §6.5 relies on to survive outcome compaction, and it is whatcountByLastRuncounts — a case would have been pinned to a run that did not produce it. Latent (the pipeline passes one run id per chunk) and structurally invisible to the floor. Now a per-row column; probe passes.The duplicate-key refusal was right in the store and wrong at the boundary. It threw inside the chunk loop, so a repeated key would fail a three-hour run outright where the per-row loop absorbed it. Reachable: the live WebChart path builds items per fetched bundle, so two bundles for one patient produce two items for one key, in the same chunk.
appendAuditswas all-or-nothing per 500-row statement, so one malformed payload cost up to 500 ledger entries against the rule that every state change is audited. A failed sub-chunk now falls back to a row at a time and still propagates.Three tests could not fail — the defect class this repo names, and my third time hitting it:
unnestpre-read — the window the batch cannot see. It belongs there because the floor has no set-based UPDATE to lose.Two comments asserted invariants that had stopped being true, and were corrected rather than left: the progress counters advance before any case is written (they still describe persisted outcomes, which is what they always counted — the comment claimed cases), and a mid-run failure is coarser than per-row, in the recoverable direction.
A reviewability fix worth its own line
Raw NUL bytes had crept into five source files as key separators. A NUL makes git and grep classify a
.tsfile as binary, so it drops out of diffs and out of every code search —audit-packet.tshad been hiding that way, which is why the whole-run read inside it survived the audit that caught the other seven. Same runtime value, written\u0000. The two stores also disagreed on what a duplicate is: the floor joined keys with a space, the ceiling with NUL, so a subject id containing a space would have been refused by one and accepted by the other.Verification
pnpm typecheckclean.Deliberately not in this PR
GET /api/runs/:id/outcomesis unbounded on the pilot profile. Bounding it means changing whatX-Total-Countpromises (it is the visible count, and visibility is an app-side predicate over a directory built from the rows), which is a contract change on an admin surface and belongs in its own decision.WORKWELL_INCREMENTAL_EVALstays off. A cache hit still persists the copied-forward outcome and still runs the case upsert, so it would have bought little while the writes dominated. It is the next lever now that they do not, and it should be turned on as a measured step rather than folded in here.Separately, and not fixed here: the 2026-09-08 six-measure run failed because merging #543 redeployed and restarted the worker mid-run, and the orphaned row then showed RUNNING for about sixteen hours before a sweep marked it FAILED. A long run cannot survive a deploy and has no resume. This shrinks the window considerably; it does not close it.